Skip to content

feat(cli): add healthcheck, foreground supervision, and configurable cert SANs - #230

Merged
LeeroyHannigan merged 1 commit into
mainfrom
feat/container-runtime-supervision
Aug 5, 2026
Merged

feat(cli): add healthcheck, foreground supervision, and configurable cert SANs#230
LeeroyHannigan merged 1 commit into
mainfrom
feat/container-runtime-supervision

Conversation

@robinnsc

@robinnsc robinnsc commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

Makes the extenddb binary supervisable and probeable from a container runtime. First of two container-readiness PRs; the migration concurrency guard follows in a separate PR.

  • extenddb healthcheck — new subcommand that sends an HTTPS GET /health and exits 0 or 1, so a Docker HEALTHCHECK needs no shell or curl and works on a distroless/scratch base. Reads the port from the config file, or takes --endpoint https://host:port. Connect, read, and write are bounded at 3s TcpStream::connect has no timeout of its own), every resolved address is tried so a name resolving to both ::1 and 127.0.0.1 works, and --endpoint accepts an optional scheme, port, path, and IPv6 literal.
  • serve --foreground writes no PID file and skips the run directory entirely, so the container can use a read-only root filesystem. Daemon mode is unchanged. With no PID file to read, stop now probes the port and reports that a server is listening under foreign supervision instead of claiming nothing is running; status already degraded to reporting an unknown PID.
  • serve --write-pid-file — opts back into the PID file in foreground mode, for shell use and for tooling that wants stop and status to work. It goes to the same run_dir path daemon mode uses, so neither command needs extra arguments, and run_dir then has to be writable. Ignored in daemon mode, which always writes one.
  • init --tls-san <name> (repeatable) — appends Subject Alternative Names to the generated self-signed certificate so it is valid for the name clients actually use, such as an in-cluster service DNS name, rather than only localhost/127.0.0.1/bind-addr. Values are trimmed and de-duplicated case-insensitively.
  • Docs: the architecture and deployment guides claimed the server always daemonizes and that foreground mode still writes a PID file. Corrected, and the container recipe that waited on that PID file is replaced with a foreground entrypoint plus a HEALTHCHECK.
  • CI/tooling — the new test file is excluded from the main pytest suite and run in devtools/run-tests' CLI section instead, alongside test_cli_lifecycle.py; like those tests it starts and stops its own servers and creates its own databases, so it cannot run in parallel against the shared instance the main suite uses. The integration workflow now starts the server with --write-pid-file, because run-tests restarts it via extenddb stop to apply an import/export config change. That restart also no longer suppresses errors — see Notable below.

Implementation Decisions

  • healthcheck is a liveness probe, deliberately. /health is a static handler that does not query the storage backend, so a replica whose database has gone away still reports healthy. That is the right behaviour for a HEALTHCHECK and a Kubernetes livenessProbe: one that failed on a database outage would restart every replica at once and prolong the outage. A backend that is unreachable at startup does stop the server from listening, so that case is caught. There is no readiness endpoint yet, and the follow-up is to add one backed by a cached storage-layer round-trip rather than to make /health query the backend and lose its value as a liveness signal. This is stated in the module docs, the admin guide, and the deployment guide so nobody wires it to a readinessProbe expecting traffic to drain.
  • init fails rather than silently dropping a --tls-san. init never regenerates an existing certificate, since rotating the key pair under a live deployment would be a surprise. That means a --tls-san added on a later run cannot take effect — and the container story generates the certificate into a persistent volume with an idempotent entrypoint that re-runs init on every start, so this is the common path, not an edge case. Exiting 0 having dropped the name leaves the operator to discover it as a client-side TLS hostname verification failure. Instead, when a certificate already exists we verify it covers every requested SAN and fail with an actionable error otherwise; an already-covered SAN is accepted, so the idempotent entrypoint still works. Certificate generation also moved ahead of all database work so a bad SAN fails before any users or databases are created.

Why

Prerequisite binary changes from the containerization design: the server daemonizes by default (so the container runtime sees PID 1 exit), needs a health probe that works without a shell, and fixes its certificate SANs to localhost/bind-addr, which is wrong for any in-cluster service name.

Testing done

New tests/test_cli_container_readiness.py, against a real PostgreSQL:

  • --tls-san adds one and multiple SANs to the generated certificate; blanks are skipped and case-insensitive duplicates appear once.
  • init fails, naming the SAN, when an existing certificate does not cover it, and does not rotate the certificate; an already-covered SAN is accepted.
  • healthcheck exits 0 when the server is up, non-zero before start and after stop, honours --endpoint including a trailing path, and fails in under 15s against an unreachable host instead of hanging for the OS connect timeout.
  • serve --foreground leaves no PID file and no run directory, answers healthcheck, is not killed by extenddb stop (which reports the port is listening), and exits on SIGTERM.

Plus 5 unit tests for --endpoint parsing (scheme, path, default port, IPv6, malformed input).

Also verified manually that the tests fail if the SAN coverage check is removed, so they are not passing by accident, and that the suite no longer touches the real ~/.extenddb/tls

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

Breaking changes

serve --foreground no longer writes a PID file or creates run_dir. On main it does both, and extenddb stop works against a foreground server. Anyone relying on that must add --write-pid-file, which restores the previous behaviour exactly. extenddb status is unaffected apart from reporting the PID as unknown, since it probes the port. Daemon mode is unchanged.

The repo's own tooling was such a consumer: devtools/run-tests restarts the server with extenddb stop, so the integration workflow passes the new flag.

One newly non-silent failure: init --tls-san X against an existing certificate that does not cover X now exits non-zero where it previously exited 0 and ignored the flag. Since --tls-san is new in this PR, no existing invocation can hit it.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

Comment thread tests/test_cli_container_readiness.py Fixed
Comment thread tests/test_cli_container_readiness.py Fixed
Comment thread tests/test_cli_container_readiness.py Fixed
@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch 2 times, most recently from 4b7c4c1 to 7aa3895 Compare July 28, 2026 10:33
@robinnsc
robinnsc marked this pull request as ready for review July 28, 2026 10:57
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Thanks @robinnsc, this is a well-put-together PR and the description matches what the code does. I built the branch and ran everything live against a real PostgreSQL before writing this, so the findings below are observed, not read.

What I verified

All claims in the description hold on this machine (release build, real Postgres):

  • serve --foreground starts, serves, and leaves no PID file and no run directory. run_dir was confirmed absent after startup.
  • healthcheck exits 0 against a live server and 1 against a down port, honours --endpoint, and failed against a blackhole host (TEST-NET 203.0.113.1) in 3.09s instead of hanging.
  • stop against a PID-file-less foreground server refuses to kill it and prints the foreign-supervision message. The server stayed healthy afterwards.
  • --write-pid-file restores status and stop exactly; both worked, and stop terminated the server cleanly.
  • SIGTERM to the foreground process drains gracefully.
  • init --tls-san produced a cert with correct SAN typing (DNS for names, IP for 10.42.0.7), case-insensitive dedup (one entry for a name passed twice in different cases), and blanks skipped. Re-running init with a covered SAN passes; an uncovered SAN fails with the actionable error, before any database work, and the cert is not rotated.
  • Suites: 13/13 Rust unit tests, 10/10 of the new pytest file, and 11 passed / 1 skipped on the existing test_cli_lifecycle.py, so no regression to the current CLI behaviour.

One functional issue worth fixing before merge

Wildcard SANs break the idempotent entrypoint. The generation path (rcgen::CertificateParams::new) accepts *.svc.cluster.local as a DNS SAN, but the coverage check calls rustls::pki_types::ServerName::try_from, which rejects wildcards. So init --tls-san '*.svc.cluster.local' succeeds on first run and then fails hard on every subsequent run, even though the cert covers the name. Since the container story re-runs init on every start, this turns a plausible in-cluster input into a startup crash loop. Either match wildcards against the cert's DNS SAN list directly, or reject them symmetrically at generation time.

Smaller observations (non-blocking)

  • cmd_healthcheck.rs:189 swallows set_read_timeout/set_write_timeout errors with let _ =. The whole point of the command is a bounded probe; a server that completes TCP connect but wedges the TLS handshake is exactly what a liveness probe exists to catch, so propagate those errors.
  • The default probe target is the literal 127.0.0.1. A deployment binding ::1 or :: gets a false unhealthy from the flagless invocation. Consider deriving the host from the config bind_addr or probing localhost across both families.
  • serve accepts --port but healthcheck does not, so serve --port X plus a bare healthcheck --config probes the wrong port. Hit this in testing; --endpoint works around it, but the asymmetry is a footgun.
  • Nit: "the names clients actually use" in init_helpers.rs; drop "actually".
  • Nit: test_healthcheck_up_and_down sleeps a fixed 1s after stop before asserting failure; polling for port-closed would be flake-proof.

The security-adjacent choices are right: skipping cert verification in the probe is correct for a self-signed liveness check and is honestly documented, and using rustls' real name verification for SAN coverage (rather than string matching) handles the IP-vs-DNS distinction properly.

Sequencing and the containerization plan

Two coordination points, neither a fault of this PR:

  1. feat: serve lib decoupling #218 (serve/lib decoupling) is likely to land today, and it moves cmd_serve.rs, cmd_init.rs, cmd_stop.rs, init_helpers.rs, and main.rs into a new crates/app/ crate. A merge simulation between the two branches shows a modify/delete conflict on cmd_serve.rs plus content conflicts in main.rs and init_helpers.rs. It is also semantic, not just textual: feat: serve lib decoupling #218 makes the PID write unconditional inside the new serve() library entrypoint, which is the opposite of this PR's foreground contract. Once feat: serve lib decoupling #218 merges, this PR should rebase and express the PID decision as a field on the new ServeParams struct (pid_file: Option<PathBuf> fits naturally). Happy to help with that rebase.

  2. Three divergences from the containerization design doc that we should reconcile on the doc side rather than block here: the doc had the healthcheck doubling as readiness (this PR correctly splits liveness out and defers readiness); the doc specified a server.pid_file config key rather than a CLI flag; and the doc chose local-CA generation where this ships a bare self-signed leaf, which also means the doc's AWS_CA_BUNDLE auto-trust flow will not work as written. Related: the migration concurrency guard the doc scoped into this same phase is deferred to your follow-up, which should be reconciled with feat: adopt sqlx::migrate for PostgreSQL catalog and data migrations (ADR-0003) #221 (sqlx::migrate adoption) before it is written, since feat: adopt sqlx::migrate for PostgreSQL catalog and data migrations (ADR-0003) #221 may change where that guard lives.

Net: fix the wildcard SAN check, rebase once #218 lands, and this is good to go from my side.

@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch from 7aa3895 to 10bb920 Compare July 30, 2026 22:34
@robinnsc

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and addressed those callouts:

  • Fixed by the wildcard SAN issue, kept wildcards working rather than rejecting them
  • set_read_timeout/set_write_timeout now propagate
  • The flagless probe now derives its host from the configured bind_addr: a wildcard bind maps to same-family loopback, anything else is probed as configured. The IPv6 concern is fair, bind_addr = "::1" binds fine, curl -g 'https://[::1]:18443/health' works, and the flagless healthcheck now exits 0 against it where it previously probed 127.0.0.1 and reported unhealthy. Unit tests cover the mapping.
  • healthcheck gained --port, with precedence --endpoint > --port > config > default.
  • The fixed 1s sleep in test_healthcheck_up_and_down is now a poll with a 15s deadline

Will address those doc gap callouts on those various design docs

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Really nice PR. I verified everything in the description live and it all holds, including the try-every-resolved-address handling, which quietly saves an IPv6-only deployment from a wrong verdict. Gates are clean (fmt, clippy -D warnings, 637 unit tests, release build), your new test file is 12/12, and the CLI section is 23 passed / 1 skipped.

One thing I would like fixed, then a few take-them-or-leave-them notes.

Please fix: healthcheck can hang forever

The 3s bounds are per-syscall, not per-operation. probe sets a read timeout and then calls tls.read_to_string(&mut response), so a server that sends anything every couple of seconds resets the clock on each read and the probe never
returns.

I reproduced it with a TLS server that completes the handshake, sends HTTP/1.1 200 OK\r\n, then emits one byte every two seconds: healthcheck was still running at 75 seconds. That is the case the module doc says the timeouts exist to catch, and read_to_string is unbounded in size too.

Two small changes close it: compute one deadline up front and shrink each read's timeout to the remaining budget, and cap the read with something like Read::take(8 * 1024) since only the status line is used. I have the reproducing test (about 40 lines of Python) if you want it.

Optional

  • stop probes ("127.0.0.1", port) at cmd_stop.rs:53, so an IPv6-only bind falls back to "No extenddb server is running", the message this change set out to avoid. cmd_status.rs on main already does this, so nothing new, but you
    solve it properly in probe_host and both sites could share it.
  • The new test file's exclusion is an --ignore flag at devtools/run-tests:444, so a bare pytest tests/ still collects it and it will fight the shared server. Same shape as test_cli_lifecycle.py, so pre-existing, but a collect_ignore in conftest.py would move the guarantee into the file.

@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch from 10bb920 to a4784df Compare August 5, 2026 09:19
Comment thread tests/test_cli_container_readiness.py Fixed
@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch from a4784df to 36544ce Compare August 5, 2026 09:40
@robinnsc

robinnsc commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, addressed those comment. The probe now creates one three second deadline up front. DNS resolution, all resolved-address connection attempts, the rustls handshake, request writes, and response reads consume that same budget. The transport wrapper recalculates the remaining time before every underlying socket read or write, including the calls rustls makes internally, so partial progress cannot reset the clock. DNS runs behind the same deadline as well, rather than leaving synchronous resolution as an unbounded step.

I also replaced the unbounded read_to_string with an 8 KiB capped byte read. Only the first HTTP status line is parsed, so reading beyond that prefix cannot improve the verdict.

There are two regression tests. The first completes TLS, receives and validates GET /health, sends HTTP/1.1 200 OK, then emits one byte per second. The old implementation remained alive until the ten-second test harness killed it; the fixed probe exits non-zero at the shared deadline, after the test has observed both the status line and partial read progress. The second sends more than 16 KiB and deliberately keeps the connection open: the fixed probe returns successfully after the 8 KiB cap instead of waiting for EOF. The TLS test helper also propagates worker-thread failures and has bounded cleanup so either test cannot pass on an unrelated prompt failure.

…cert SANs

Make the binary supervisable and probeable from a container runtime. First of
two changes for container readiness; the migration concurrency guard follows
separately.

- healthcheck: new subcommand that probes /health over HTTPS and exits 0 or 1,
  so a Docker HEALTHCHECK needs no shell or curl and works on distroless. It
  reports liveness, which is what a HEALTHCHECK and a Kubernetes livenessProbe
  want: /health is a static handler that does not query the backend, and a
  liveness probe that failed on a database outage would restart every replica
  at once. There is no readiness endpoint yet; adding one backed by a cached
  storage-layer round-trip is the follow-up. Address resolution, connect, TLS
  handshake, request write, and response read share one 3s deadline. The
  remaining budget is applied before every underlying socket call, so partial
  progress cannot reset the timeout, and the response is capped at 8 KiB since
  only its status line is used. Every resolved address is tried so a name
  resolving to both ::1 and 127.0.0.1 works, and --endpoint accepts an optional
  scheme, port, path, and IPv6 literal. The flagless probe derives its host from
  the configured bind_addr rather than assuming 127.0.0.1, so an IPv6-bound
  server is not reported unhealthy, and --port mirrors serve's own override.
  Timeout-configuration failures are propagated, since a bounded probe is the
  whole point of the command.
- serve --foreground: write no PID file and skip the run directory by default,
  so the container can use a read-only root filesystem. Daemon mode is
  unchanged. With no PID file to read, `stop` now probes the port and reports
  that a server is listening under foreign supervision rather than claiming
  nothing is running; `status` already degrades to an unknown PID.
- serve --write-pid-file: opt back into the PID file in foreground mode, for
  shell use and tooling that wants `stop` and `status` to work. It goes to the
  same run_dir path daemon mode uses, so neither command needs extra arguments,
  and run_dir then has to be writable. Ignored in daemon mode, which always
  writes one. devtools/run-tests restarts the server with `stop` to apply a
  config change, so the integration workflow passes this flag; without it that
  restart silently did nothing: `stop` failed, `serve` could not bind, and the
  health check passed against the process that was never replaced. That path
  no longer suppresses errors either, so a failed restart fails the run instead
  of reporting success.
- init --tls-san <name> (repeatable): append Subject Alternative Names to the
  generated self-signed certificate so it is valid for the name clients use,
  such as an in-cluster service DNS name, not just
  localhost/127.0.0.1/bind-addr. Values are trimmed and de-duplicated
  case-insensitively. init never regenerates an existing certificate, so a
  later --tls-san cannot take effect; rather than exit 0 having dropped the
  name and leave clients to hit a TLS hostname verification failure, it
  verifies the existing certificate covers every requested SAN and fails with
  an actionable error otherwise. Certificate generation moved ahead of all
  database work so a bad SAN fails before any state is created. The coverage
  check uses rustls's own `verify_server_name`, so it adds no new dependency. A
  wildcard such as *.svc.cluster.local is a valid certificate entry but not a
  valid server name, so coverage is tested by substituting a single label;
  without that, a wildcard accepted on the first run failed on every later one,
  which is a crash loop for the idempotent entrypoint. Every requested name is
  validated before generation, so a malformed wildcard fails on the first run
  rather than the next.
- docs: correct the architecture and deployment guides, which claimed the
  server always daemonizes and that foreground mode still writes a PID file,
  and replace the container recipe that waited on that PID file with a
  foreground entrypoint plus a HEALTHCHECK.
- tests: add tests/test_cli_container_readiness.py covering SAN generation,
  dedup/blank handling, the not-covered failure and the already-covered
  idempotent case, healthcheck up/down/--endpoint, prompt failure against an
  unreachable host, a TLS peer that drips one byte per second, an oversized
  response held open past the 8 KiB cap, and foreground leaving no PID file or
  run directory while still exiting on SIGTERM. Runs under an isolated $HOME
  so the suite no longer overwrites the developer's real ~/.extenddb
  certificate.
- devtools/run-tests: exclude the new file from the main pytest suite and run
  it in the CLI section instead, alongside test_cli_lifecycle.py. Like those
  tests it starts and stops its own servers and creates its own databases, so
  it cannot run in parallel against the shared instance the main suite uses.

Rebased onto the post-#218 layout: the CLI now lives in crates/app, so
cmd_healthcheck joins it there. ServeParams gains pid_file: Option<PathBuf> in
place of run_dir, which it only ever used to derive that path, so serve() no
longer writes a PID file unconditionally and needs no notion of a run
directory.
@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch from 36544ce to fc5f80a Compare August 5, 2026 19:14
@LeeroyHannigan
LeeroyHannigan added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit e0d6a0a Aug 5, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants